Add realm-server /_delegate-session endpoint for user-scoped read-only JWTs#5287
Add realm-server /_delegate-session endpoint for user-scoped read-only JWTs#5287lukemelia wants to merge 2 commits into
Conversation
…y JWTs Mints a 30-minute, single-realm, read-only JWT for a named user, authenticated by a shared-secret HMAC over the request body + timestamp (±60s replay window). ai-bot uses this to read a realm on a user's behalf without a blanket grant — avoiding the confused-deputy exfiltration risk of giving @AIBot blanket realm read access (CS-11552; security design CS-11551). - utils/delegation.ts: HMAC-SHA256 sign/verify with a ±60s timestamp window, constant-time comparison; the secret never crosses the wire. - handlers/handle-delegate-session.ts: verify the signature, look up the named user's permissions on the realm, mint a `delegated` read-only token, and audit-log the outcome with a correlation id. - runtime-common/realm.ts: a `delegated` token is accepted only for read operations and only when the bound user actually has read, so the exact- permissions-match invariant stays intact for normal sessions. - AI_BOT_DELEGATION_SECRET is optional; when unset the endpoint returns 503. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Host Test Results 1 files ±0 1 suites ±0 1h 43m 56s ⏱️ + 1m 11s Results for commit 866653f. ± Comparison against earlier commit d9e1390. Realm Server Test Results 1 files ±0 1 suites ±0 9m 36s ⏱️ - 3m 30s Results for commit 866653f. ± Comparison against earlier commit d9e1390. |
lukemelia
left a comment
There was a problem hiding this comment.
[Codex] Code review findings from a focused pass on delegated-token scope and permission parity.
| // indirection applies. Enforce instead the two guarantees the delegation | ||
| // design promises: the session is read-only, and it grants no more than | ||
| // the bound user can already read. | ||
| if (token.delegated) { |
There was a problem hiding this comment.
[Codex] This delegated-token branch does not check that the JWT's realm claim matches the realm currently handling the request (requestContext.realm.url / this.url). Because delegated tokens are signed with the realm-server seed and this branch skips the normal exact-permissions match, a token minted for realm A can be replayed against realm B on the same server whenever the bound user also has read on B. That breaks the advertised single-realm scope. Please reject delegated tokens whose normalized token.realm differs from the current realm before allowing the read.
There was a problem hiding this comment.
[Claude Code 🤖] Good catch — fixed in 866653f. The delegated branch in assertRequestPermissions now rejects a token whose normalized realm claim differs from the realm handling the request (ensureTrailingSlash(token.realm) !== ensureTrailingSlash(this.url)) before allowing the read, restoring single-realm scope. Added a test that mints a validly-signed delegated token for a different realm and asserts it's rejected (401) against this realm even though the bound user has read here.
| userId: onBehalfOf, | ||
| onlyOwnRealms: false, | ||
| }); | ||
| let userPermissions = permissionsForAllRealms[normalizedRealmHref] ?? []; |
There was a problem hiding this comment.
[Codex] This mint-time check uses fetchUserPermissions, which includes exact-user rows and public * grants, but the live realm authorizer also grants users permissions to Matrix users whose profile exists (RealmPermissionChecker.for). For a realm with users: ['read'], the user really can read the realm, but this endpoint will return 403 because there is no exact row for onBehalfOf. Since the endpoint is supposed to mint when the bound user actually has read, this should mirror RealmPermissionChecker or explicitly include validated users grants, with a test for that case.
There was a problem hiding this comment.
[Claude Code 🤖] Agreed — fixed in 866653f. The mint-time check now uses RealmPermissionChecker (built from fetchRealmPermissions + the realm-server's matrixClient) and .can(onBehalfOf, 'read'), so it resolves *, exact rows, and users grants identically to the realm authorizer. A realm with users: ['read'] no longer 403s a profile-bearing user. Added two tests: minting succeeds for a users-grant user with a Matrix profile, and is denied when no profile exists.
Addresses review findings on PR #5287: - realm.ts: reject a delegated token whose `realm` claim does not match the realm handling the request. Delegated tokens are signed with the shared realm-server seed and this branch skips the exact-permissions match, so without the check a token minted for realm A could be replayed against realm B on the same server whenever the bound user also has read on B. - handle-delegate-session.ts: decide read access with RealmPermissionChecker (exact row + `*` + `users` grants) rather than the raw realm_user_permissions rows, so the mint decision matches what the realm authorizer would accept — a `users: ['read']` realm no longer 403s a profile-bearing user. - Tests: cross-realm token rejection, and minting via a `users` grant (granted with a Matrix profile, denied without). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What & why
New shared-secret-authenticated realm-server endpoint,
POST /_delegate-session, that mints a 30-minute, single-realm, read-only JWT for a named user:ai-bot will use this (CS-11553 / CS-11554) to read a realm on a user's behalf without a blanket
@aibotread grant — avoiding the confused-deputy exfiltration risk where any room member could point a skills state event at someone else's realm. Implements CS-11552; follows the v1 security design settled in CS-11551 (Spec §4).How it works
${timestamp}.${rawBody}with the shared secret, sent asx-boxel-delegation-signature+x-boxel-delegation-timestampheaders. Constant-time compare, ±60s timestamp window. The secret never crosses the wire, so the window genuinely bounds replays (TLS + rotation remain the defenses against leak — rotation tooling is CS-11567).onBehalfOf's permissions on the realm; requiresread(else 403). The token grants exactly the read access the user already has.createJWT({ user: onBehalfOf, realm, permissions: ['read'], delegated: true }, '30m', realmSecretSeed).Realm-side acceptance
A
["read"]-only token would otherwise be rejected by the realm's exact-permissions-match check (realm.ts) on a private realm where the user has write/owner perms. SoassertRequestPermissionsnow accepts adelegatedtoken only for read operations and only when the bound user actually has read — a write attempt gets 403, and the exact-match invariant is untouched for all normal sessions.Decisions worth a reviewer's eye
AI_BOT_DELEGATION_SECRETis optional, not required inmain.ts. Unset → the endpoint returns 503 and mints nothing, so deployments aren't forced to provision a secret for a feature nothing consumes yet. Provisioning/rotation lands with CS-11553 / CS-11567.iss/aud/sub/jticlaims are subsumed by the existing realm-token shape the verifier actually reads (user= subject,realm= audience,realmServerURL= issuer); the audit log is the forensic recordjtiwas for.Testing
tests/server-endpoints/delegate-session-test.ts— 12 tests: claim assertions, end-to-end realm read accepted for a realm-owner user, write rejected (read-only), auth failures (missing/invalid sig, wrong secret, stale/future timestamp), 403 no-read, 400 bad/missing body.🤖 Generated with Claude Code